Skip to content

install: reject package names and aliases longer than 255 bytes - #41730

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/2bc7e8f3/lockfile-name-length
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/2bc7e8f3/lockfile-name-length

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A bun.lock packages entry with a very long ident name aborts bun install --frozen-lockfile. Isolated linker: panic: internal error: entered unreachable code in bun_paths::Path::append_fmt (src/paths/Path.rs:1014) on a thread-pool thread, under Installer::append_store_path. Hoisted linker: panic: range end index 1000004 out of range for slice of length 4096 in ByteCursor::put (PackageManagerDirectories.rs:459).
  • Cause: is_safe_install_folder_name (src/install/dependency.rs:537), which the bun.lock parser applies to the name, has no length bound. Both linkers copy the name into fixed-size path buffers without a check. The isolated linker's bounded check runs inside the loop that starts tasks, so a worker races it.

Fix

  • is_safe_install_folder_name and alias_is_safe_install_target reject names longer than 255 bytes (NAME_MAX, above npm's 214 byte limit). The parser reports Invalid package name.
  • The isolated installer validates every name and alias before the first task starts. The hoisted installer checks an npm package's name before it prints the cache folder name.
  • Correct because every name is far below the smallest path buffer (1024 bytes on macOS) before any unchecked copy.
  • Verified: test/cli/install/bun-install-registry.test.ts, 8 cases on both linkers, 6 fail on canary. Six other install suites pass. Self-reviewed: 9 concerns, 7 addressed, 2 left out (see Notes).

Background

  • A bun.lock packages entry is "<key>": ["<name>@<resolution>", ...]. The name becomes the node_modules/<name> folder and the cache folder name.
  • MAX_PATH_BYTES is the size of bun's pooled path buffers: 4096 on Linux, 1024 on macOS. bun_paths::Path in ASSUME mode indexes straight into one.
  • The isolated linker runs each store entry as a thread-pool task. A task builds the store paths of the entry's dependencies, which the main thread may not have validated yet.
Notes
  • Repro: package.json {"dependencies":{"no-deps":"1.0.0"}}, bun.lock with "packages": {"no-deps": ["e" + "A"*N + "vil@1.0.0", "<tarball url>", {}, "<sha512>"]}, then bun install --frozen-lockfile --linker isolated|hoisted. On canary: N around 2500 to 4095 panics the isolated linker in Path::append (index out of bounds: the len is 4096 but the index is 4096), N of 4096 or more panics it in Path::append_fmt some of the time and prints is not a valid install folder name the rest of the time (the race), and N of 4096 or more panics the hoisted linker in ByteCursor::put every time. Found by a text-lockfile fuzz.
  • The tree builder reports Invalid dependency name for an alias over the bound, through the same predicate.
  • The first version of this change bounded the name at MAX_PATH_BYTES. Review showed that names between about 2000 and 4095 bytes still aborted the isolated linker (the store path embeds the name twice plus the project directory) and names just under 4096 still overflowed the hoisted cache folder name, so the bound is now 255.
  • Not addressed here: a registry that serves a manifest for a name longer than 255 bytes reaches cached_npm_package_folder_name through determine_preinstall_state on a fresh resolve. install: reject dependency names containing control characters #38615 adds name validation at enqueue through the same predicate, so it picks up this bound. The v1 git .bun-tag / committish inputs of the same ByteCursor are a separate input and are not touched.
  • install: reject dependency names containing control characters #38615 edits the same two predicates (control characters). The conflict is mechanical.
  • Suites run: isolated-install, bun-install-registry, bun-install, bun-lock, bun-workspaces, bun-add, migration/migrate. The bun-install.test.ts gitlab/bitbucket/some.url cases fail in this container with and without the change (no public internet). bun-link.test.ts should link dependency without crashing fails on a debug build of main too.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/install/bun-install-registry.test.ts

The name from a bun.lock packages entry was copied into fixed-size path
buffers by both linkers without a length check. The hoisted linker
panicked in ByteCursor::put when it printed the cache folder name. The
isolated linker panicked on a worker thread in Path::append_fmt /
Path::append when it built the store path of a dependency, before the
main thread had validated that entry's name.

is_safe_install_folder_name and alias_is_safe_install_target now reject
a name longer than 255 bytes (NAME_MAX, above npm's 214 byte limit), so
the bun.lock parser reports "Invalid package name" and the tree
builder reports "Invalid dependency name". The hoisted installer also
checks an npm package's own name before it prints the cache folder
name, for names that arrive through other lockfile formats. The
isolated installer validates every name and alias before it starts the
first task, so that check no longer races the workers.
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 2 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 54e1206d-5ce8-48b2-8f12-0b157008405f

📥 Commits

Reviewing files that changed from the base of the PR and between d316760 and 904da3e.

📒 Files selected for processing (4)
  • src/install/PackageInstaller.rs
  • src/install/dependency.rs
  • src/install/isolated_install.rs
  • test/cli/install/bun-install-registry.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 6, 2026
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:38 AM PT - Sep 6th, 2026

⏳ @robobun, your commit 904da3e is still building in Build #111651, but has 1 failures so far (All Failures):

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on canary (1.4.3-canary.1) with a bun.lock whose packages entry is "no-deps": ["e" + "A"*1000000 + "vil@1.0.0", <tarball>, {}, <sha512>] and bun install --frozen-lockfile:

  • --linker isolated: panic: internal error: entered unreachable code (in Path::append_fmt, on a worker thread) on some runs, error: "eAAA...vil" is not a valid install folder name on others.
  • --linker hoisted: panic: range end index 1000004 out of range for slice of length 4096 every run.
  • A 3000 byte name: panic: index out of bounds: the len is 4096 but the index is 4096 in the isolated linker.

The new tests in test/cli/install/bun-install-registry.test.ts (package names longer than 255 bytes in bun.lock) fail 6 of 8 with USE_SYSTEM_BUN=1 and pass 8 of 8 with this branch.

CI (build 111651): green for this diff on every lane that ran, including macOS aarch64. The one red test, test/js/node/test/parallel/test-crypto-dh-leak.js on debian x64-asan, fails on main too and is unrelated to this change. Ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes untrusted-input path validation in both linkers and reorders the isolated installer's validation relative to worker-thread task dispatch, a human look at the security-sensitive bits would still be worthwhile.

What was reviewed:

  • The 255-byte bound in is_safe_install_folder_name / alias_is_safe_install_target and its callers — every existing caller (bun.lock parser, Tree builder, tarball extract, bin) now picks up the length check.
  • The hoisted-linker addition that also validates pkg_name (not just the alias) for npm resolutions before it reaches the cache-folder path.
  • The isolated-install hoist: the new upfront loop indexes entry_node_ids by the same entry index the task-start loop uses, so it validates the same set of names/aliases the old in-loop check did, just before any start_task runs.
  • Tests: describe.each over both linkers, boundary at 214 accepted / 256+ rejected, uses local Verdaccio, asserts stderr before exit code.
Extended reasoning...

Overview

This PR hardens bun install against overlong package names in bun.lock that previously reached fixed-size MAX_PATH_BYTES path buffers unchecked and panicked (index-out-of-bounds in Path::append / ByteCursor::put). It adds MAX_INSTALL_FOLDER_NAME_LEN = 255 in src/install/dependency.rs and applies it in both is_safe_install_folder_name and alias_is_safe_install_target (the latter previously bounded at MAX_PATH_BYTES, which was insufficient because store paths embed the name twice plus a prefix). In src/install/PackageInstaller.rs the hoisted linker now also validates an npm resolution's own package name — not just the dependency alias — since that name becomes the cache folder path. In src/install/isolated_install.rs the per-entry name/alias validation is lifted out of the task-start loop into a separate pass over all entries so no worker thread can build a store path for an unvalidated dependency name. Eight new test cases in test/cli/install/bun-install-registry.test.ts cover both linkers at 214 (accepted) and 256/3000/1,000,000 bytes (rejected with "Invalid package name").

Security risks

The change is squarely in the "untrusted lockfile string becomes a filesystem path" category REVIEW.md calls out. It strictly tightens validation (adds a length upper bound; no check is loosened), so the risk surface is reduced rather than expanded. The new bound of 255 is above npm's 214-byte package-name limit and equals NAME_MAX, so no legitimate package name is newly rejected. The isolated-linker reordering closes a race where a worker could build a store path for a dependency whose entry the main thread hadn't validated yet. I don't see a new bypass: the length check is applied at the shared predicate every consumer already routes through (bun.lock parser, tree builder, tarball extract, bin linker, both installers), so the whole class picks it up. The remaining gap the PR notes (registry-served manifests reaching cached_npm_package_folder_name on fresh resolve) is explicitly deferred to #38615, which reuses the same predicate.

Level of scrutiny

Medium-high. The diff is small (~120 lines) and mechanical in shape, but it sits on a security boundary (path construction from attacker-controllable lockfile content) and adjusts the ordering of validation relative to multi-threaded task dispatch in the isolated installer. The race-fix reasoning depends on entry_dependencies, entry_node_ids, and store.entries all being indexed by the same entry index — which the surrounding code confirms (the old in-loop check used entry_dependencies[entry_id.get() as usize] and the task loop uses entry_node_ids[entry_id.get() as usize]), but a maintainer familiar with the isolated store layout should confirm no entry is reachable by a worker that isn't covered by the upfront enumeration.

Other factors

Tests follow repo conventions well: describe.each over both linkers, Buffer.alloc(n, fill).toString() for long strings, local Verdaccio via registryUrl(), pipes drained concurrently, stderr asserted before exit code, and boundary coverage (npm's 214-byte limit accepted, one past the new 255-byte bound rejected). No CODEOWNERS cover the touched paths. The PR description is unusually thorough (repro, root cause, why 255 not MAX_PATH_BYTES, what's deferred). Given the security-adjacent nature and the thread-ordering argument, deferring for a human confirmation rather than auto-approving.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

I built this branch merged with #38615 on main at 630e921 to check the path listed under "Not addressed here".

Result. The two changes together close that path. Neither closes it alone.

The case: the registry manifest of a package lists a dependency whose name is longer than the path buffer, and the root project depends on that package. On main, bun install aborts in ByteCursor::put (range end index 4200 out of range for slice of length 4096) through determine_preinstall_state.

With both merged, the enqueue check from #38615 calls is_safe_install_folder_name, which has the 255 byte bound from this PR. The name is rejected before any request is made for it. bun install prints failed to resolve and exits with code 1.

I ran names of 4090 and 4200 bytes on the hoisted and isolated linkers. The name was a direct dependency, a transitive dependency, or reached through bun add. Every case aborts on the 1.4.3 canary and exits with code 1 on the merge. A 256 byte name is rejected. A 255 byte name passes the check.

Merge conflicts. The two PRs conflict in dependency.rs, PackageInstaller.rs and isolated_install.rs. Each resolution is the union of both sides:

cargo check -p bun_install passes on the result, and a debug build of it ran the cases above.

Test gap. No test in either PR covers the registry case, because it fails on each branch alone. The PR that lands second should add one: a stub registry whose manifest for package A lists a dependency with a 4200 byte name, a root project that depends on A, and assertions on failed to resolve and exit code 1. Without it, a later change to either check can reopen the abort with no failing test.

One observation that this PR does not change: names of 240 and 255 bytes pass the check but fail later with ENAMETOOLONG on the rename into the cache, because the temporary directory name adds a prefix to the package name. The 1.4.3 canary behaves the same way, and it exits with code 1.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #43067, which contains this change and its tests. The 255-byte name bound is what closes the registry manifest and lockfile triggers there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant